Passing TVPs Using ADO.NET
We’ll conclude our discussion of TVPs with the new SqlDbType.Structured enumeration in ADO.NET. You will learn how easy it is to marshal multiple rows of data from your client application to SQL Server, without requiring multiple roundtrips or implementing special logic on the server for processing the data.
Simply prepare an ordinary SqlCommand object , set its CommandType property to CommandType.StoredProcedure, and populate its Parameters collection with SqlParameter objects. These are the routine steps for setting up a call to any stored procedure. Then, to mark a SqlParameter as a TVP, set its SqlDbType property to SqlDbType.Structured. This lets you specify any DataTable, DbDataReader, or IEnumerable<SqlDataRecord> object as the parameter value to be passed to the stored procedure in a single call to the server.
In Example 6, a new customer order is stored in separate Order and OrderDetail DataTable objects within a DataSet. The two tables are passed to the SQL Server stored procedure you saw earlier (Example 2 in the section Submitting Orders), which accepts them as TVPs for insertion into the Order and OrderDetail
database tables.
Example 6. Passing DataTable objects as TVPs to a SQL Server stored procedure from ADO.NET.
// Assumes conn is an open SqlConnection object and ds is
// a DataSet with an Order and OrderDetails table
using(conn)
{
// Create the command object to call the stored procedure
SqlCommand cmd = new SqlCommand("uspInsertNewOrder", conn);
cmd.CommandType = CommandType.StoredProcedure;
// Create the parameter for passing the Order TVP
SqlParameter headerParam = cmd.Parameters.AddWithValue
("@OrderHeaderTvp", ds.Tables["OrderHeader"]
);
// Create the parameter for passing the OrderDetails TVP
SqlParameter detailsParam = cmd.Parameters.AddWithValue
("@OrderDetailsTvp", ds.Tables["OrderDetail"]
);
// Set the SqlDbType of the parameters to Structured
headerParam.SqlDbType = SqlDbType.Structured;
detailsParam.SqlDbType = SqlDbType.Structured;
// Execute the stored procedure
cmd.ExecuteNonQuery();
}
This code calls a SQL
Server stored procedure and passes to it an order header and the
complete set of order details in a single roundtrip. Remarkably, it’s
just as simple as that. You just need to ensure that the schema of the DataTable objects match the corresponding TVP-table-type schema.
You
can also send a set of rows directly to a parameterized SQL statement
without creating a stored procedure. Because the SQL statement is
dynamically constructed on the client, there is no stored procedure
signature that specifies the name of the user-defined table type for the
TVP. Therefore, you need to tell ADO.NET what the type is by setting
the TypeName property to the name of the table type defined on the server. For example, the code in Example 7 passes a DataTable to a parameterized SQL statement.
Example 7. Passing TVPs to a parameterized SQL statement from ADO.NET.
// Define the INSERT INTO...SELECT statement to insert into Categories
const string TSqlStatement = @"
INSERT INTO Categories (CategoryID, CategoryName)
SELECT nc.CategoryID, nc.CategoryName
FROM @NewCategoriesTvp AS nc";
// Assumes conn is an open SqlConnection object and ds is
// a DataSet with a Category table
using(conn)
{
// Set up the command object for the statement
SqlCommand cmd = new SqlCommand(TSqlStatement, conn);
// Add a TVP specifying the DataTable as the parameter value
SqlParameter catParam = cmd.Parameters.AddWithValue
("@NewCategoriesTvp", ds.Tables["Category"]);
catParam.SqlDbType = SqlDbType.Structured;
catParam.TypeName = "dbo.CategoriesUdt";
// Execute the command
cmd.ExecuteNonQuery();
}
Setting the TypeName property to dbo.CategoriesUdt in this code means that you have a user-defined table type by that name on the server, created using the CREATE TYPE…AS TABLE statement that defines the CategoryID and CategoryName columns.
You can also use any object derived from DbDataReader (that is, a connected data source) to stream rows of data to a TVP. The example shown in Example 8 calls an Oracle stored procedure to select data from an Oracle database into a connected OracleDataReader. The reader object gets passed as a single table-valued input parameter to a SQL Server stored procedure, which can then use the Oracle data in the reader as the source for adding new rows into the Category table in the SQL
Server database (for this code to work, you must have the ADO.NET
Provider for Oracle installed and a project reference set to System.Data.OracleClient, as well as access to an Oracle database server).
Example 8. Passing a connected OracleDataReader source as a TVP to SQL Server.
// Set up command object to select from Oracle
OracleCommand selCmd = new OracleCommand
("SELECT CategoryID, CategoryName FROM Categories;", oracleConn);
// Execute the command and return the results in a connected
// reader that will automatically close the connection when done
OracleDataReader rdr = selCmd.ExecuteReader(CommandBehavior.CloseConnection);
// Set up command object to insert to SQL Server
SqlCommand insCmd = new SqlCommand("uspInsertCategories", connection);
insCmd.CommandType = CommandType.StoredProcedure;
// Add a TVP specifying the reader as the parameter value
SqlParameter catParam = cmd.Parameters.AddWithValue("@NewCategoriesTvp", rdr
);
catParam.SqlDbType = SqlDbType.Structured;
// Execute the stored procedure
insertCommand.ExecuteNonQuery();
Passing Collections to TVPs Using Custom Iterators
What if you’re working with collections populated with business objects rather than DataTable objects populated with DataRow
objects? You might not think at first that business objects and TVPs
could work together, but the fact is that they can—and quite gracefully,
too. All you need to do is implement the IEnumerable<SqlDataRecord> interface in your collection class. This interface requires your collection class to supply a custom iterator method named GetEnumerator, which ADO.NET will call for each object contained in the collection when you invoke ExecuteNonQuery.
Let’s demonstrate with the same order entry example as Example 6, only now you’ll use ordinary business object collections (rather than DataTable and DataRow objects) as the source for the TVPs. You have OrderHeader and OrderDetail classes with properties to match the corresponding TVP-table-type schemas, as shown in Example 9.
Of course, a real implementation could include much more than this
simple data transfer object, such as encapsulated business logic.
Example 2-9. Defining classes for passing business objects as TVPs.
public class OrderHeader
{
public int OrderId { get; set; }
public int CustomerId { get; set; }
public DateTime OrderedAt { get; set; }
}
public class OrderDetail
{
public int OrderId { get; set; }
public int LineNumber { get; set; }
public int ProductId { get; set; }
public int Quantity { get; set; }
public decimal Price { get; set; }
}
Ordinarily, working with List<OrderHeader> and List<OrderDetail> objects might be a suitable option for containing collections of OrderHeader and OrderDetail objects in your application. But in these collections they won’t suffice on their own as input values for TVPs because List<T> does not implement IEnumerable<SqlDataRecord>. The solution is to create OrderHeaderCollection and OrderDetailCollection classes that inherit List<OrderHeader> and List<OrderDetail> respectively, and then implement IEnumerable<SqlDataRecord> in order to “TVP-enable” them as shown in Example 10.
Example 10. Defining collection classes with custom iterators for passing business objects as TVPs.
public class OrderHeaderCollection : List<OrderHeader>, IEnumerable<SqlDataRecord>
{
IEnumerator<SqlDataRecord> IEnumerable<SqlDataRecord>.GetEnumerator()
{
var sdr = new SqlDataRecord(
new SqlMetaData("OrderId", SqlDbType.Int),
new SqlMetaData("CustomerId", SqlDbType.Int),
new SqlMetaData("OrderedAt", SqlDbType.Date));
foreach (OrderHeader oh in this)
{
sdr.SetInt32(0, oh.OrderId);
sdr.SetInt32(1, oh.CustomerId);
sdr.SetDateTime(2, oh.OrderedAt);
yield return
sdr;
}
}
}
public class OrderDetailCollection : List<OrderDetail>, IEnumerable<SqlDataRecord>
{
IEnumerator<SqlDataRecord> IEnumerable<SqlDataRecord>.GetEnumerator()
{
var sdr = new SqlDataRecord(
new SqlMetaData("OrderId", SqlDbType.Int),
new SqlMetaData("LineNumber", SqlDbType.Int),
new SqlMetaData("ProductId", SqlDbType.Int),
new SqlMetaData("Quantity", SqlDbType.Int),
new SqlMetaData("Price", SqlDbType.Money));
foreach (OrderDetail od in this)
{
sdr.SetInt32(0, od.OrderId);
sdr.SetInt32(1, od.LineNumber);
sdr.SetInt32(2, od.ProductId);
sdr.SetInt32(3, od.Quantity);
sdr.SetDecimal(4, od.Price);
yield return
sdr;
}
}
}
We’ll only explain the OrderHeaderCollection class; you’ll be able to infer how the OrderDetailCollection class—or any of your own collection classes—implements the custom iterator needed to support TVPs.
First, again, it inherits List<OrderHeader>, so an OrderHeaderCollection object is everything that a List<OrderHeader> object is. This means implicitly, by the way, that it also implements IEnumerable<OrderHeader>, which is what makes any sequence “foreach-able” or “LINQ-able.” But to the heart of our discussion, it explicitly implements IEnumerable<SqlDataRecord>, which means it has a custom iterator method for ADO.NET to consume when an instance of this collection class is assigned to a SqlDbType.Structured parameter for piping over to SQL Server with a TVP.
Every enumerable class requires a matching enumerator method, so not surprisingly implementing IEnumerable<SqlDataRecord> requires providing a GetEnumerator method that returns an IEnumerator<SqlDataRecord>. This method first initializes a new SqlDataRecord
object with a schema that matches the table-type schema that the TVPs
are declared as. It then enters a loop that iterates all the elements in
the collection (possible because List<OrderHeader> implicitly implements IEnumerable<OrderHeader>). On the first iteration, it sets the column property values of the SqlDataRecord object to the property values of the first OrderHeader element, and then issues the magic yield return statement. By definition, any method (like this one) which returns IEnumerator<T> and has a yield return statement in it is a custom iterator method; it is expected to return a sequence of T objects until the method execution path completes (in this case, when the foreach loop finishes).
The crux of this is that you are never calling this method directly. Instead, when you invoke ExecuteNonQuery to run a stored procedure with a SqlDbType.Structured parameter (that is, a TVP), ADO.NET expects the collection passed for the parameter value to implement IEnumerable<SqlDataRecord> so that IEnumerable<SqlDataRecord>.GetEnumerator
can be called internally to fetch each new record for piping over to
the server. When the first element is fetched from the collection, GetEnumerator is entered, the SqlDataRecord is initialized, and is then populated with values using the SetInt32 and SetDateTime methods (there’s a SetXXX method for each data type). That SqlDataRecord “row” is then pushed into the pipeline to the server by yield return. When the next element is fetched from the collection, the GetEnumerator method resumes from the point that it yield returned the previous element, rather than entering GetEnumerator again from the top. This means the SqlDataRecord
gets initialized with schema information only once, while its
population with one element after another is orchestrated by the
controlling ADO.NET code for ExecuteNonQuery that actually ships one SqlDataRecord after another to the server.
The actual code to call the stored procedure is 100 percent identical to the code in Example 6 that uses a DataTable rather than a collection. Substituting a collection for a DataTable object requires no code changes and works flawlessly, provided the collection implements IEnumerator<SqlDataRecord>. This mean the collection has a GetEnumerator method that feeds each object instance to a SqlDataRecord and maps each object property to a column defined by the user-defined table type that the TVP is declared as.
TVPs have several noteworthy limitations. First and foremost, once TVPs are initially populated and passed, they are read-only structures. The READONLY keyword must be applied to TVPs in the signatures of your stored procedures, or they will not compile. Similarly, the OUTPUT
keyword cannot be used. You cannot update the column values in the rows
of a TVP, and you cannot insert or delete rows. If you must modify the
data in a TVP, you need to implement a workaround, such as inserting
data from the TVP into a temporary table or into a table variable to
which you can then apply changes.
There is no ALTER TABLE…AS TYPE
statement that supports changing the schema of a TVP table type.
Instead, you must first drop all stored procedures and UDFs that
reference the type before you can drop the type, re-create it with a new
schema, and then re-create the stored procedures. Indexing is limited
as well, with support only for PRIMARY KEY and UNIQUE constraints. Also, statistics on TVPs are not maintained by SQL Server.
It’s also important to note that the Entity Framework does not support TVPs. However, you can easily send any collection of
Entity Framework entities to a stored procedure that accepts TVPs by
coding a custom iterator just like the one you created in the section Passing Collections to TVPs Using Custom Iterators.